Skip to content

webpack的核心要点梳理总结

本文就Webpack中涉及的核心要点进行总结,涉及基本的概念,打包的流程和原理,底层的事件API,以及loader和plugin的编写和应用。

基本概念

在webpack中有入口(entry)、模块(module)、代码块(chunk)、模块装换器(loader)和插件(plugin)的概念。

  • Entry:⼊⼝,Webpack 执⾏构建的第⼀步将从 Entry 开始,可抽象成输⼊;
  • Module:模块,在 Webpack ⾥⼀切皆模块,⼀个模块对应着⼀个⽂件。Webpack 会从配置的Entry 开始递归找出所有依赖的模块;
  • Chunk:代码块,⼀个 Chunk 由多个模块组合⽽成,⽤于代码合并与分割;
  • Loader:模块转换器,⽤于把模块原内容按照需求转换成新内容;
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会⼴播出对应的事件,插件可以监听这些事件的发⽣,在特定时机做对应的事情;

流程概括

Webpack 的运⾏流程是⼀个串⾏的过程,从启动到结束会依次执⾏以下流程:

Alt text

  1. 初始化参数:从配置⽂件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:⽤上⼀步得到的参数初始化 Compiler 对象,加载所有配置的插件,执⾏对象的 run ⽅法开始执⾏编译;
  3. 确定⼊⼝:根据配置中的 entry 找出所有的⼊⼝⽂件;
  4. 编译模块:从⼊⼝⽂件出发,调⽤所有配置的 Loader 对模块进⾏翻译,再找出该模块依赖的模块,再递归本步骤直到所有⼊⼝依赖的⽂件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使⽤ Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据⼊⼝和模块之间的依赖关系,组装成⼀个个包含多个模块的 Chunk,再把每个Chunk 转换成⼀个单独的⽂件加⼊到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和⽂件名,把⽂件内容写⼊到⽂件系统。

在以上过程中,Webpack 会在特定的时间点⼴播出特定的事件,插件在监听到感兴趣的事件后会执⾏特定的逻辑,并且插件可以调⽤ Webpack 提供的 API 改变 Webpack 的运⾏结果。

流程细节

Webpack 的构建流程可以分为以下三⼤阶段:

  1. 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler;
  2. 编译:从 Entry 发出,针对每个 Module 串⾏调⽤对应的 Loader 去翻译⽂件内容,再找到该Module 依赖的 Module,递归地进⾏编译处理;
  3. 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成⽂件,输出到⽂件系统;如果只执⾏⼀次构建,以上阶段将会按照顺序各执⾏⼀次。但在开启监听模式下,流程将变为如下:

Alt text

在每个⼤阶段中⼜会发⽣很多事件,Webpack 会把这些事件⼴播出来供给 Plugin 使⽤。

初始化阶段

事件名含义
初始化参数从配置⽂件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执⾏配置⽂件中的插件实例化语句 new Plugin()。
实例化 Compiler⽤上⼀步得到的参数初始化 Compiler 实例,Compiler 负责⽂件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有⼀个Compiler 实例。
加载插件依次调⽤插件的 apply ⽅法,让插件可以监听后续的所有事件节点。同时给插件传⼊ compiler 实例的引⽤,以⽅便插件通过 compiler 调⽤ Webpack 提供的 API。
environment开始应⽤ Node.js ⻛格的⽂件系统到 compiler 对象,以⽅便后续的⽂件寻找
和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化⼀个对应的 EntryPlugin,为后⾯该Entry 的递归解析⼯作做准备。
after-plugins调⽤完所有内置的和配置的插件的 apply ⽅法。
after-resolvers根据配置初始化完 resolver,resolver 负责在⽂件系统中寻找指定路径的⽂件。

编译阶段

事件名含义
run启动⼀次新的编译。
watch-run和 run 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些⽂件发⽣了变化导致重新启动⼀次新的编译。
compile该事件是为了告诉插件⼀次新的编译将要启动,同时会给插件带上 compiler对象。
compilation当 Webpack 以开发模式运⾏时,每当检测到⽂件变化,⼀次新的Compilation 将被创建。⼀个 Compilation 对象包含了当前的模块资源、编译⽣成资源、变化的⽂件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make⼀个新的 Compilation 创建完毕,即将从 Entry 开始读取⽂件,根据⽂件类型和配置的 Loader 对⽂件进⾏编译,编译完后再找出该⽂件依赖的⽂件,递归的编译和解析。
after-compile⼀次 Compilation 执⾏完成。
invalid当遇到⽂件不存在、⽂件编译错误等异常时会触发该事件,该事件不会导致Webpack 退出。

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调⽤了 Loader 完成了每个模块的转换操作,在 compilation 阶段⼜包括很多⼩的事件,它们分别是:

事件名含义
build-module使⽤对应的 Loader 去转换⼀个模块。
normal-module-loader在⽤ Loader 对⼀个模块转换完后,使⽤ acorn 解析转换后的内容,输出对应的抽象语法树(AST),以⽅便 Webpack 后⾯对代码的分析。
program从配置的⼊⼝模块开始,分析其 AST,当遇到 require 等导⼊其它模块语句时,便将其加⼊到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始⽣成 Chunk。

输出阶段

事件名含义
should-emit所有需要输出的⽂件已经⽣成好,询问插件哪些⽂件需要输出,哪些不需要。
emit确定好要输出哪些⽂件后,执⾏⽂件输出,可以在这⾥获取和修改输出内容。
after-emit⽂件输出完毕。
done成功完成⼀次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在⼀起形成⼀个个 Chunk。 在输出阶段会根据 Chunk 的类型,使⽤对应的模版⽣成最终要要输出的⽂件内容。

输出文件分析

Webpack 输出的 bundle.js 是什么样⼦的吗? 为什么原来⼀个个的模块⽂件被合并成了⼀个单独的⽂ 件?为什么 bundle.js 能直接运⾏在浏览器中?

我们来看一下最简单的项⽬构建出的 bundle.js ⽂件内容:

js
(
  // webpackBootstrap 启动函数
  // modules 即为存放所有模块的数组,数组中的每⼀个元素都是⼀个函数
  function (modules) {
    // 安装过的模块都存放在这⾥⾯
    // 作⽤是把已经加载过的模块缓存在内存中,提升性能
    var installedModules = {};
    // 去数组中加载⼀个模块,moduleId 为要加载模块在数组中的 index
    // 作⽤和 Node.js 中 require 语句相似
    function __webpack_require__(moduleId) {
      // 如果需要加载的模块已经被加载过,就直接从内存缓存中返回
      if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
      }
      // 如果缓存中不存在需要加载的模块,就新建⼀个模块,并把它存在缓存中
      var module = installedModules[moduleId] = {
        // 模块在数组中的 index
        i: moduleId,
        // 该模块是否已经加载完毕
        l: false,
        // 该模块的导出值
        exports: {}
      };
      // 从 modules 中获取 index 为 moduleId 的模块对应的函数
      // 再调⽤这个函数,同时把函数需要的参数传⼊
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
      // 把这个模块标记为已加载
      module.l = true;
      // 返回这个模块的导出值
      return module.exports;
    }
    // Webpack 配置中的 publicPath,⽤于加载被分割出去的异步代码
    __webpack_require__.p = "";
    // 使⽤ __webpack_require__ 去加载 index 为 0 的模块,并且返回该模块导出的内容
    // index 为 0 的模块就是 main.js 对应的⽂件,也就是执⾏⼊⼝模块
    // __webpack_require__.s 的含义是启动模块对应的 index
    return __webpack_require__(__webpack_require__.s = 0);
  })(
    // 所有的模块都存放在了⼀个数组⾥,根据每个模块在数组的 index 来区分和定位模块
    [
      /* 0 */
      (function (module, exports, __webpack_require__) {
        // 通过 __webpack_require__ 规范导⼊ show 函数,show.js 对应的模块index 为 1
        const show = __webpack_require__(1);
        // 执⾏ show 函数
        show('Webpack');
      }),
      /* 1 */
      (function (module, exports) {
        function show(content) {
          window.document.getElementById('app').innerText = 'Hello,' + content;
        }
        // 通过 CommonJS 规范导出 show 函数
        module.exports = show;
      })
    ]
  );
(
  // webpackBootstrap 启动函数
  // modules 即为存放所有模块的数组,数组中的每⼀个元素都是⼀个函数
  function (modules) {
    // 安装过的模块都存放在这⾥⾯
    // 作⽤是把已经加载过的模块缓存在内存中,提升性能
    var installedModules = {};
    // 去数组中加载⼀个模块,moduleId 为要加载模块在数组中的 index
    // 作⽤和 Node.js 中 require 语句相似
    function __webpack_require__(moduleId) {
      // 如果需要加载的模块已经被加载过,就直接从内存缓存中返回
      if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
      }
      // 如果缓存中不存在需要加载的模块,就新建⼀个模块,并把它存在缓存中
      var module = installedModules[moduleId] = {
        // 模块在数组中的 index
        i: moduleId,
        // 该模块是否已经加载完毕
        l: false,
        // 该模块的导出值
        exports: {}
      };
      // 从 modules 中获取 index 为 moduleId 的模块对应的函数
      // 再调⽤这个函数,同时把函数需要的参数传⼊
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
      // 把这个模块标记为已加载
      module.l = true;
      // 返回这个模块的导出值
      return module.exports;
    }
    // Webpack 配置中的 publicPath,⽤于加载被分割出去的异步代码
    __webpack_require__.p = "";
    // 使⽤ __webpack_require__ 去加载 index 为 0 的模块,并且返回该模块导出的内容
    // index 为 0 的模块就是 main.js 对应的⽂件,也就是执⾏⼊⼝模块
    // __webpack_require__.s 的含义是启动模块对应的 index
    return __webpack_require__(__webpack_require__.s = 0);
  })(
    // 所有的模块都存放在了⼀个数组⾥,根据每个模块在数组的 index 来区分和定位模块
    [
      /* 0 */
      (function (module, exports, __webpack_require__) {
        // 通过 __webpack_require__ 规范导⼊ show 函数,show.js 对应的模块index 为 1
        const show = __webpack_require__(1);
        // 执⾏ show 函数
        show('Webpack');
      }),
      /* 1 */
      (function (module, exports) {
        function show(content) {
          window.document.getElementById('app').innerText = 'Hello,' + content;
        }
        // 通过 CommonJS 规范导出 show 函数
        module.exports = show;
      })
    ]
  );

以上看上去复杂的代码其实是⼀个⽴即执⾏函数,可以简写为如下:

js
(function (modules) {
  // 模拟 require 语句
  function __webpack_require__() {
  }
  // 执⾏存放所有模块数组中的第0个模块
  __webpack_require__(0);
})([/*存放所有模块的数组*/])
(function (modules) {
  // 模拟 require 语句
  function __webpack_require__() {
  }
  // 执⾏存放所有模块数组中的第0个模块
  __webpack_require__(0);
})([/*存放所有模块的数组*/])

bundle.js 能直接运⾏在浏览器中的原因在于输出的⽂件中通过 webpack_require 函数定义了⼀个可以在浏览器中执⾏的加载函数来模拟 Node.js 中的 require 语句。

原来⼀个个独⽴的模块⽂件被合并到了⼀个单独的 bundle.js 的原因在于浏览器不能像 Node.js 那样快速地去本地加载⼀个个模块⽂件,⽽必须通过⽹络请求去加载还未得到的⽂件。 如果模块数量很多,加载时间会很⻓,因此把所有模块都存放在了数组中,执⾏⼀次⽹络加载。

如果仔细分析 webpack_require 函数的实现,还会发现 Webpack 做了缓存优化: 执⾏加载过的模块不会再执⾏第⼆次,执⾏结果会缓存在内存中,当某个模块第⼆次被访问时会直接去内存中读取被缓存的返回值。

分割代码的输出

在按需加载时的优化⽅案中,webpack的输出⽂件会发⽣变化:

js
// 异步加载 show.js
import('./show').then((show) => {
  // 执⾏ show 函数
  show('Webpack');
});
// 异步加载 show.js
import('./show').then((show) => {
  // 执⾏ show 函数
  show('Webpack');
});

重新构建后会输出两个⽂件,分别是执⾏⼊⼝⽂件 bundle.js 和 异步加载⽂件 0.bundle.js。其中 0.bundle.js 内容如下:

js
// 加载在本⽂件(0.bundle.js)中包含的模块
webpackJsonp(
  // 在其它⽂件中存放着的模块的 ID
  [0],
  // 本⽂件所包含的模块
  [
    // show.js 所对应的模块
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }
      module.exports = show;
    })
  ]
);
// 加载在本⽂件(0.bundle.js)中包含的模块
webpackJsonp(
  // 在其它⽂件中存放着的模块的 ID
  [0],
  // 本⽂件所包含的模块
  [
    // show.js 所对应的模块
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }
      module.exports = show;
    })
  ]
);

bundle.js 内容如下:

js
(function (modules) {
  /***
  * webpackJsonp ⽤于从异步加载的⽂件中安装模块。
  * 把 webpackJsonp 挂载到全局是为了⽅便在其它⽂件中调⽤。
  *
  * @param chunkIds 异步加载的⽂件中存放的需要安装的模块对应的 Chunk ID
  * @param moreModules 异步加载的⽂件中存放的需要安装的模块列表
  * @param executeModules 在异步加载的⽂件中存放的需要安装的模块都安装成功后,需要执⾏的模块对应的 index
  */
  window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    // 把 moreModules 添加到 modules 对象中
    // 把所有 chunkIds 对应的模块都标记成已经加载成功
    var moduleId, chunkId, i = 0, resolves = [], result;
    for (; i < chunkIds.length; i++) {
      chunkId = chunkIds[i];
      if (installedChunks[chunkId]) {
        resolves.push(installedChunks[chunkId][0]);
      }
      installedChunks[chunkId] = 0;
    }
    for (moduleId in moreModules) {
      if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
        modules[moduleId] = moreModules[moduleId];
      }
    }
    while (resolves.length) {
      resolves.shift()();
    }
  };
  // 缓存已经安装的模块
  var installedModules = {};
  // 存储每个 Chunk 的加载状态;
  // 键为 Chunk 的 ID,值为0代表已经加载成功
  var installedChunks = {
    1: 0
  };
  // 模拟 require 语句,和上⾯介绍的⼀致
  function __webpack_require__(moduleId) {
    // ... 省略和上⾯⼀样的内容
  }
  /**
 * ⽤于加载被分割出去的,需要异步加载的 Chunk 对应的⽂件
 * @param chunkId 需要异步加载的 Chunk 对应的 ID
 * @returns {Promise}
 */
  __webpack_require__.e = function requireEnsure(chunkId) {
    // 从上⾯定义的 installedChunks 中获取 chunkId 对应的 Chunk 的加载状态
    var installedChunkData = installedChunks[chunkId];
    // 如果加载状态为0表示该 Chunk 已经加载成功了,直接返回 resolve Promise
    if (installedChunkData === 0) {
      return new Promise(function (resolve) {
        resolve();
      });
    }
    // installedChunkData 不为空且不为0表示该 Chunk 正在⽹络加载中
    if (installedChunkData) {
      // 返回存放在 installedChunkData 数组中的 Promise 对象
      return installedChunkData[2];
    }
    // installedChunkData 为空,表示该 Chunk 还没有加载过,去加载该 Chunk 对应的⽂件
    var promise = new Promise(function (resolve, reject) {
      installedChunkData = installedChunks[chunkId] = [resolve, reject];
    });
    installedChunkData[2] = promise;
    // 通过 DOM 操作,往 HTML head 中插⼊⼀个 script 标签去异步加载 Chunk 对应的 JavaScript ⽂件
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.charset = 'utf-8';
    script.async = true;
    script.timeout = 120000;
    // ⽂件的路径为配置的 publicPath、chunkId 拼接⽽成
    script.src = __webpack_require__.p + "" + chunkId + ".bundle.js";
    // 设置异步加载的最⻓超时时间
    var timeout = setTimeout(onScriptComplete, 120000);
    script.onerror = script.onload = onScriptComplete;
    // 在 script 加载和执⾏完成时回调
    function onScriptComplete() {
      // 防⽌内存泄露
      script.onerror = script.onload = null;
      clearTimeout(timeout);
      // 去检查 chunkId 对应的 Chunk 是否安装成功,安装成功时才会存在于 installed Chunks 中
      var chunk = installedChunks[chunkId];
      if (chunk !== 0) {
        if (chunk) {
          chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
        }
        installedChunks[chunkId] = undefined;
      }
    };
    head.appendChild(script);
    return promise;
  };
  // 加载并执⾏⼊⼝模块,和上⾯介绍的⼀致
  return __webpack_require__(__webpack_require__.s = 0);
})
  (
    // 存放所有没有经过异步加载的,随着执⾏⼊⼝⽂件加载的模块
    [
      // main.js 对应的模块
      (function (module, exports, __webpack_require__) {
        // 通过 __webpack_require__.e 去异步加载 show.js 对应的 Chunk
        __webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
          // 执⾏ show 函数
          show('Webpack');
        });
      })
    ]
  );
(function (modules) {
  /***
  * webpackJsonp ⽤于从异步加载的⽂件中安装模块。
  * 把 webpackJsonp 挂载到全局是为了⽅便在其它⽂件中调⽤。
  *
  * @param chunkIds 异步加载的⽂件中存放的需要安装的模块对应的 Chunk ID
  * @param moreModules 异步加载的⽂件中存放的需要安装的模块列表
  * @param executeModules 在异步加载的⽂件中存放的需要安装的模块都安装成功后,需要执⾏的模块对应的 index
  */
  window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    // 把 moreModules 添加到 modules 对象中
    // 把所有 chunkIds 对应的模块都标记成已经加载成功
    var moduleId, chunkId, i = 0, resolves = [], result;
    for (; i < chunkIds.length; i++) {
      chunkId = chunkIds[i];
      if (installedChunks[chunkId]) {
        resolves.push(installedChunks[chunkId][0]);
      }
      installedChunks[chunkId] = 0;
    }
    for (moduleId in moreModules) {
      if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
        modules[moduleId] = moreModules[moduleId];
      }
    }
    while (resolves.length) {
      resolves.shift()();
    }
  };
  // 缓存已经安装的模块
  var installedModules = {};
  // 存储每个 Chunk 的加载状态;
  // 键为 Chunk 的 ID,值为0代表已经加载成功
  var installedChunks = {
    1: 0
  };
  // 模拟 require 语句,和上⾯介绍的⼀致
  function __webpack_require__(moduleId) {
    // ... 省略和上⾯⼀样的内容
  }
  /**
 * ⽤于加载被分割出去的,需要异步加载的 Chunk 对应的⽂件
 * @param chunkId 需要异步加载的 Chunk 对应的 ID
 * @returns {Promise}
 */
  __webpack_require__.e = function requireEnsure(chunkId) {
    // 从上⾯定义的 installedChunks 中获取 chunkId 对应的 Chunk 的加载状态
    var installedChunkData = installedChunks[chunkId];
    // 如果加载状态为0表示该 Chunk 已经加载成功了,直接返回 resolve Promise
    if (installedChunkData === 0) {
      return new Promise(function (resolve) {
        resolve();
      });
    }
    // installedChunkData 不为空且不为0表示该 Chunk 正在⽹络加载中
    if (installedChunkData) {
      // 返回存放在 installedChunkData 数组中的 Promise 对象
      return installedChunkData[2];
    }
    // installedChunkData 为空,表示该 Chunk 还没有加载过,去加载该 Chunk 对应的⽂件
    var promise = new Promise(function (resolve, reject) {
      installedChunkData = installedChunks[chunkId] = [resolve, reject];
    });
    installedChunkData[2] = promise;
    // 通过 DOM 操作,往 HTML head 中插⼊⼀个 script 标签去异步加载 Chunk 对应的 JavaScript ⽂件
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.charset = 'utf-8';
    script.async = true;
    script.timeout = 120000;
    // ⽂件的路径为配置的 publicPath、chunkId 拼接⽽成
    script.src = __webpack_require__.p + "" + chunkId + ".bundle.js";
    // 设置异步加载的最⻓超时时间
    var timeout = setTimeout(onScriptComplete, 120000);
    script.onerror = script.onload = onScriptComplete;
    // 在 script 加载和执⾏完成时回调
    function onScriptComplete() {
      // 防⽌内存泄露
      script.onerror = script.onload = null;
      clearTimeout(timeout);
      // 去检查 chunkId 对应的 Chunk 是否安装成功,安装成功时才会存在于 installed Chunks 中
      var chunk = installedChunks[chunkId];
      if (chunk !== 0) {
        if (chunk) {
          chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
        }
        installedChunks[chunkId] = undefined;
      }
    };
    head.appendChild(script);
    return promise;
  };
  // 加载并执⾏⼊⼝模块,和上⾯介绍的⼀致
  return __webpack_require__(__webpack_require__.s = 0);
})
  (
    // 存放所有没有经过异步加载的,随着执⾏⼊⼝⽂件加载的模块
    [
      // main.js 对应的模块
      (function (module, exports, __webpack_require__) {
        // 通过 __webpack_require__.e 去异步加载 show.js 对应的 Chunk
        __webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
          // 执⾏ show 函数
          show('Webpack');
        });
      })
    ]
  );

这⾥的 bundle.js 和上⾯所讲的 bundle.js ⾮常相似,区别在于:

  • 多了⼀个 webpack_require.e ⽤于加载被分割出去的,需要异步加载的 Chunk 对应的⽂件;
  • 多了⼀个 webpackJsonp 函数⽤于从异步加载的⽂件中安装模块;

在使⽤了 CommonsChunkPlugin 去提取公共代码时输出的⽂件和使⽤了异步加载时输出的⽂件是⼀样的,都会有 webpack_require.e 和 webpackJsonp 。 原因在于提取公共代码和异步加载本质上都是代码分割。

编写loader

Loader 就像是⼀个翻译员,能把源⽂件经过转化后输出新的结果,并且⼀个⽂件还可以链式的经过多个翻译员翻译。以处理 SCSS ⽂件为例:

  1. SCSS 源代码会先交给 sass-loader 把 SCSS 转换成 CSS;
  2. 把 sass-loader 输出的 CSS 交给 css-loader 处理,找出 CSS 中依赖的资源、压缩 CSS等;
  3. 把 css-loader 输出的 CSS 交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码;

可以看出以上的处理过程需要有顺序的链式执⾏,先 sass-loader 再 css-loader 再 style-loader 。 以上处理的Webpack 相关配置如下:

js
module.exports = {
  module: {
    rules: [
      {
        // 增加对 SCSS ⽂件的⽀持
        test: /\.scss$/,
        // SCSS ⽂件的处理顺序为先 sass-loader 再 css-loader 再 style-loader
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            // 给 css-loader 传⼊配置项
            options: {
              minimize: true,
            }
          },
          'sass-loader'],
      },
    ]
  },
};
module.exports = {
  module: {
    rules: [
      {
        // 增加对 SCSS ⽂件的⽀持
        test: /\.scss$/,
        // SCSS ⽂件的处理顺序为先 sass-loader 再 css-loader 再 style-loader
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            // 给 css-loader 传⼊配置项
            options: {
              minimize: true,
            }
          },
          'sass-loader'],
      },
    ]
  },
};

Loader的职责

由上⾯的例⼦可以看出:⼀个 Loader 的职责是单⼀的,只需要完成⼀种转换。 如果⼀个源⽂件需要经历多步转换才能正常使⽤,就通过多个 Loader 去转换。 在调⽤多个 Loader 去转换⼀个⽂件时,每个Loader 会链式的顺序执⾏, 第⼀个 Loader 将会拿到需处理的原内容,上⼀个 Loader 处理后的结果会传给下⼀个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。

所以,在你开发⼀个 Loader 时,请保持其职责的单⼀性,你只需关⼼输⼊和输出。

Loader的基础

由于 Webpack 是运⾏在 Node.js 之上的,⼀个 Loader 其实就是⼀个 Node.js 模块,这个模块需要导出⼀个函数。 这个导出的函数的⼯作就是获得处理前的原内容,对原内容执⾏处理后,返回处理后的内容。

⼀个最简单的 Loader 的源码如下:

js
module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的⼀个⽂件的原内容
  // 该函数需要返回处理后的内容,这⾥简单起⻅,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};
module.exports = function(source) {
  // source 为 compiler 传递给 Loader 的⼀个⽂件的原内容
  // 该函数需要返回处理后的内容,这⾥简单起⻅,直接把原内容返回了,相当于该 Loader 没有做任何转换
  return source;
};

由于 Loader 运⾏在 Node.js 中,你可以调⽤任何 Node.js ⾃带的 API,或者安装第三⽅模块进⾏调⽤:

js
const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};
const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};

Loader的进阶

以上只是个最简单的 Loader,Webpack 还提供⼀些 API 供 Loader 调⽤。

获得 Loader 的 options

在最上⾯处理 SCSS ⽂件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在⾃⼰编写的 Loader 中获取到⽤户传⼊的 options 呢?需要这样做:

js
const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 获取到⽤户给当前 Loader 传⼊的 options
  const options = loaderUtils.getOptions(this);
  return source;
};
const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 获取到⽤户给当前 Loader 传⼊的 options
  const options = loaderUtils.getOptions(this);
  return source;
};

返回其他结果

上⾯的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东⻄。例如以⽤ babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以⽅便调试源码。 为了把 Source Map 也⼀起随着 ES5 代码返回给 Webpack,可以这样写:

js
module.exports = function(source) {
  // 通过 this.callback 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps);
  // 当你使⽤ this.callback 返回内容时,该 Loader 必须返回 undefined,
  // 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,⽽不是 return 中
  return;
};
module.exports = function(source) {
  // 通过 this.callback 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps);
  // 当你使⽤ this.callback 返回内容时,该 Loader 必须返回 undefined,
  // 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,⽽不是 return 中
  return;
};

其中的 this.callback 是 Webpack 给 Loader 注⼊的 API,以⽅便 Loader 和 Webpack 之间通信。this.callback 的详细使⽤⽅法如下:

js
this.callback(
  // 当⽆法转换原内容时,给 Webpack 返回⼀个 Error
  err: Error | null,
  // 原内容转换后的内容
  content: string | Buffer,
  // ⽤于把转换后的内容得出原内容的 Source Map,⽅便调试
  sourceMap?: SourceMap,
  // 如果本次转换为原内容⽣成了 AST 语法树,可以把这个 AST 返回,
  // 以⽅便之后需要 AST 的 Loader 复⽤该 AST,以避免重复⽣成 AST,提升性能
  abstractSyntaxTree?: AST
);
this.callback(
  // 当⽆法转换原内容时,给 Webpack 返回⼀个 Error
  err: Error | null,
  // 原内容转换后的内容
  content: string | Buffer,
  // ⽤于把转换后的内容得出原内容的 Source Map,⽅便调试
  sourceMap?: SourceMap,
  // 如果本次转换为原内容⽣成了 AST 语法树,可以把这个 AST 返回,
  // 以⽅便之后需要 AST 的 Loader 复⽤该 AST,以避免重复⽣成 AST,提升性能
  abstractSyntaxTree?: AST
);

Source Map 的⽣成很耗时,通常在开发环境下才会⽣成 Source Map,其它环境下不⽤⽣成,以加速构建。 为此 Webpack 为 Loader 提供了this.sourceMap API 去告诉 Loader 当前构建环境下⽤户是否需要 Source Map。 如果你编写的 Loader 会⽣成 Source Map,请考虑到这点。

同步 & 异步

Loader 有同步和异步之分,上⾯介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过⽹络请求才能得出结果,如果采⽤同步的⽅式⽹络请求就会阻塞整个构建,导致构建⾮常缓慢。在转换步骤是异步时,可以这样:

js
module.exports = function(source) {
  // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
  var callback = this.async();
  someAsyncOperation(source, function(err, result, sourceMaps, ast) {
  // 通过 callback 返回异步执⾏后的结果
  callback(err, result, sourceMaps, ast);
  });
};
module.exports = function(source) {
  // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
  var callback = this.async();
  someAsyncOperation(source, function(err, result, sourceMaps, ast) {
  // 通过 callback 返回异步执⾏后的结果
  callback(err, result, sourceMaps, ast);
  });
};

处理⼆进制数据

在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下Loader 不是处理⽂本⽂件,⽽是处理⼆进制⽂件,例如 file-loader,就需要 Webpack 给 Loader 传⼊⼆进制格式的数据。 为此,需要这样编写 Loader:

js
module.exports = function(source) {
  // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
  source instanceof Buffer === true;
  // Loader 返回的类型也可以是 Buffer 类型的
  // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
  return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要⼆进制数据
module.exports.raw = true;
module.exports = function(source) {
  // 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
  source instanceof Buffer === true;
  // Loader 返回的类型也可以是 Buffer 类型的
  // 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
  return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要⼆进制数据
module.exports.raw = true;

以上代码中最关键的代码是最后⼀⾏ module.exports.raw = true;,没有该⾏ Loader 只能拿到字符串。

缓存加速

在有些情况下,有些转换操作需要⼤量计算⾮常耗时,如果每次构建都重新执⾏重复的转换操作,构建将会变得⾮常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的⽂件或者其依赖的⽂件没有发⽣变化时, 是不会重新调⽤对应的 Loader 去执⾏转换操作的。如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:

js
module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};
module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};

其他Loader API

除了以上提到的在 Loader 中能调⽤的 Webpack API 外,还存在以下常⽤ API:

  • this.context:当前处理⽂件的所在⽬录,假如当前 Loader 处理的⽂件是 /src/main.js,则
  • this.context 就等于 /src;
  • this.resource:当前处理⽂件的完整请求路径,包括 querystring,例如 /src/main.js?name=1;
  • this.resourcePath:当前处理⽂件的路径,例如 /src/main.js;
  • this.resourceQuery:当前处理⽂件的 querystring;
  • this.target:等于 Webpack 配置中的 Target;
  • this.loadModule:当 Loader 在处理⼀个⽂件时,如果依赖其它⽂件的处理结果才能得出当前⽂件的结果时, 就可以通过 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去获得 request 对应⽂件的处理结果;
  • this.resolve:像 require 语句⼀样获得指定⽂件的完整路径,使⽤⽅法为 resolve(context: string,request: string, callback: function(err, result: string));
  • this.addDependency:给当前处理⽂件添加其依赖的⽂件,以便再其依赖的⽂件发⽣变化时,会重新调⽤ Loader 处理该⽂件。使⽤⽅法为 addDependency(file: string);
  • this.addContextDependency:和 addDependency 类似,但 addContextDependency 是把整个⽬录加⼊到当前正在处理⽂件的依赖中。使⽤⽅法为 addContextDependency(directory: string);
  • this.clearDependencies:清除当前正在处理⽂件的所有依赖,使⽤⽅法为 clearDependencies();
  • this.emitFile:输出⼀个⽂件,使⽤⽅法为 emitFile(name: string, content: Buffer|string, sourceMap: {...}) ;

加载本地Loader

在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常⼯作,需要把它配置到 Webpack 中后,才可能会调⽤该 Loader。 上⽂中使⽤的 Loader 都是通过 Npm 安装的,要使⽤ Loader 时会直接使⽤ Loader 的名称,代码如下:

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader'],
      },
    ]
  },
};
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader'],
      },
    ]
  },
};

如果还采取以上的⽅法去使⽤本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules ⽬录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项⽬使⽤。 解决以上问题的便捷⽅法有两种,分别如下:

npm link

Npm link 专⻔⽤于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的⼀个正在开发的模块的源码链接到项⽬的 node_modules ⽬录下,让项⽬可以直接使⽤本地的 Npm 模块。 由于是通过软链接的⽅式实现的,编辑了本地的 Npm 模块代码,在项⽬中也能使⽤到编辑后的代码。

完成 Npm link 的步骤如下:

  1. 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的 package.json 已经正确配置好;
  2. 在本地 Npm 模块根⽬录下执⾏ npm link,把本地模块注册到全局;
  3. 在项⽬根⽬录下执⾏ npm link loader-name,把第2步注册到全局的本地 Npm 模块链接到项⽬的 node_modules 下,其中的 loader-name 是指在第1步中的 package.json ⽂件中配置的模块名称;

链接好 Loader 到项⽬后你就可以像使⽤⼀个真正的 Npm 模块⼀样使⽤本地的 Loader 了。

ResolveLoader

为了让 Webpack 加载放在本地项⽬中的 Loader 需要修改 resolveLoader.modules。假如本地的 Loader 在项⽬⽬录中的 ./loaders/loader-name 中,则需要如下配置:

js
module.exports = {
  resolveLoader:{
    // 去哪些⽬录下寻找 Loader,有先后顺序之分
    modules: ['node_modules','./loaders/'],
  }
}
module.exports = {
  resolveLoader:{
    // 去哪些⽬录下寻找 Loader,有先后顺序之分
    modules: ['node_modules','./loaders/'],
  }
}

加上以上配置后, Webpack 会先去 node_modules 项⽬下寻找 Loader,如果找不到,会再去./loaders/ ⽬录下寻找。

实战

创建名为 comment-require-loader ,作⽤是把 JavaScript 代码中的注释语法,转换成 CSS的引入。

js
// @require '../style/index.css'
// 转为
require('../style/index.css');
// @require '../style/index.css'
// 转为
require('../style/index.css');

该 Loader 的使⽤场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的⽅式加载依赖的 CSS ⽂件。该 Loader 的使⽤⽅法如下:

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 针对采⽤了 fis3 CSS 导⼊语法的 JavaScript ⽂件通过 comment-require-loader 去转换
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 针对采⽤了 fis3 CSS 导⼊语法的 JavaScript ⽂件通过 comment-require-loader 去转换
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};

具体的代码实现:

js
function replace(source) {
  // 使⽤正则把 // @require '../style/index.css' 转换成 require('../style/index.css'); 
  return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2); ');
}
module.exports = function (content) {
  return replace(content);
};
function replace(source) {
  // 使⽤正则把 // @require '../style/index.css' 转换成 require('../style/index.css'); 
  return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2); ');
}
module.exports = function (content) {
  return replace(content);
};

编写plugin

Webpack 通过 Plugin 机制让其更加灵活,以适应各种应⽤场景。 在 Webpack 运⾏的⽣命周期中会⼴播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。⼀个最基础的 Plugin 的代码是这样的:

js
class BasicPlugin {
  // 在构造函数中获取⽤户给该插件传⼊的配置
  constructor(options) {}
  // Webpack 会调⽤ BasicPlugin 实例的 apply ⽅法给插件实例传⼊ compiler 对象
  apply(compiler) {
    compiler.plugin('compilation', function (compilation) {})
  }
}
// 导出 Plugin
module.exports = BasicPlugin;
class BasicPlugin {
  // 在构造函数中获取⽤户给该插件传⼊的配置
  constructor(options) {}
  // Webpack 会调⽤ BasicPlugin 实例的 apply ⽅法给插件实例传⼊ compiler 对象
  apply(compiler) {
    compiler.plugin('compilation', function (compilation) {})
  }
}
// 导出 Plugin
module.exports = BasicPlugin;

在使⽤这个 Plugin 时,相关配置代码如下:

js
const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[ new BasicPlugin(options), ]
}
const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[ new BasicPlugin(options), ]
}

Webpack 启动后,在读取配置的过程中会先执⾏ new BasicPlugin(options) 初始化⼀个BasicPlugin 获得其实例。 在初始化 compiler 对象后,再调⽤ basicPlugin.apply(compiler)给插件实例传⼊ compiler 对象。 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack ⼴播出来的事件。 并且可以通过 compiler 对象去操作 Webpack。

通过以上最简单的 Plugin 相信你⼤概明⽩了 Plugin 的⼯作原理,但实际开发中还有很多细节需要注意:

Compiler 和 Compilation

在开发 Plugin 时最常⽤的两个对象就是 Compiler 和 Compilation ,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:

  • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯⼀的,可以简单地把它理解为Webpack 实例;
  • Compilation 对象包含了当前的模块资源、编译⽣成资源、变化的⽂件等。当 Webpack 以开发模式运⾏时,每当检测到⼀个⽂件变化,⼀次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象;

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的⽣命周期,⽽Compilation 只是代表了⼀次新的编译。

事件流

Webpack 就像⼀条⽣产线,要经过⼀系列处理流程后才能将源⽂件转换成输出结果。 这条⽣产线上的每个处理流程的职责都是单⼀的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下⼀个流程去处理。 插件就像是⼀个插⼊到⽣产线中的⼀个功能,在特定的时机对⽣产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的⽣产线。 Webpack 在运⾏过程中会⼴播事件,插件只需要监听它所关⼼的事件,就能加⼊到这条⽣产线中,去改变⽣产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

Webpack 的事件流机制应⽤了观察者模式,和 Node.js 中的 EventEmitter ⾮常相似。 Compiler 和 Compilation 都继承⾃ Tapable,可以直接在 Compiler 和 Compilation 对象上⼴播和监听事件,⽅法如下:

js
/**
* ⼴播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name', params);
/**
* 监听名称为 event-name 的事件,当 event-name 事件发⽣时,函数就会被执⾏。
* 同时函数中的 params 参数为⼴播事件时附带的参数。
*/
compiler.plugin('event-name', function(params) {});
/**
* ⼴播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name', params);
/**
* 监听名称为 event-name 的事件,当 event-name 事件发⽣时,函数就会被执⾏。
* 同时函数中的 params 参数为⼴播事件时附带的参数。
*/
compiler.plugin('event-name', function(params) {});

同理,compilation.apply 和 compilation.plugin 使⽤⽅法和上⾯⼀致。

在开发插件时,你可能会不知道该如何下⼿,因为你不知道该监听哪个事件才能完成任务。

在开发插件时,还需要注意以下两点:

  • 只要能拿到 Compiler 或 Compilation 对象,就能⼴播出新的事件,所以在新开发的插件中也能⼴播出事件,给其它插件监听使⽤。
  • 传给每个插件的 Compiler 和 Compilation 对象都是同⼀个引⽤。也就是说在⼀个插件中修改了Compiler 或 Compilation 对象上的属性,会影响到后⾯的插件。
  • 有些事件是异步的,这些异步的事件会附带两个参数,第⼆个参数为回调函数,在插件处理完任务时需要调⽤回调函数通知 Webpack,才会进⼊下⼀处理流程。例如:
js
compiler.plugin('emit',function(compilation, callback) {
  // 处理完毕后执⾏ callback 以通知 Webpack
  // 如果不执⾏ callback,运⾏流程将会⼀直卡在这不往下执⾏
  callback();
});
compiler.plugin('emit',function(compilation, callback) {
  // 处理完毕后执⾏ callback 以通知 Webpack
  // 如果不执⾏ callback,运⾏流程将会⼀直卡在这不往下执⾏
  callback();
});

常用API

插件可以⽤来修改输出⽂件、增加输出⽂件、甚⾄可以提升 Webpack 性能、等等,总之插件通过调⽤Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API ⾮常多,有很多 API 很少⽤的上,下⾯来介绍⼀些常⽤的 API:

读取输出资源、代码块、模块及其依赖

有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下⼀步处理。在 emit 事件发⽣时,代表源⽂件的转换和组装已经完成,在这⾥可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:

js
class Plugin {
  apply(compiler) {
    compiler.plugin('emit', function (compilation, callback) {
      // compilation.chunks 存放所有代码块,是⼀个数组
      compilation.chunks.forEach(function (chunk) {
        // chunk 代表⼀个代码块
        // 代码块由多个模块组成,通过 chunk.forEachModule 能读取组成代码块的每个模块
        chunk.forEachModule(function (module) {
          // module 代表⼀个模块
          // module.fileDependencies 存放当前模块的所有依赖的⽂件路径,是⼀个数组
          module.fileDependencies.forEach(function (filepath) {});
        });
        // Webpack 会根据 Chunk 去⽣成输出的⽂件资源,每个 Chunk 都对应⼀个及其以上的输出⽂件
        // 例如在 Chunk 中包含了 CSS 模块并且使⽤了 ExtractTextPlugin 时,
        // 该 Chunk 就会⽣成 .js 和 .css 两个⽂件
        chunk.files.forEach(function (filename) {
          // compilation.assets 存放当前所有即将输出的资源
          // 调⽤⼀个输出资源的 source() ⽅法能获取到输出资源的内容
          let source = compilation.assets[filename].source();
        });
      });
      // 这是⼀个异步事件,要记得调⽤ callback 通知 Webpack 本次事件监听处理结束。
      // 如果忘记了调⽤ callback,Webpack 将⼀直卡在这⾥⽽不会往后执⾏。
      callback();
    })
  }
}
class Plugin {
  apply(compiler) {
    compiler.plugin('emit', function (compilation, callback) {
      // compilation.chunks 存放所有代码块,是⼀个数组
      compilation.chunks.forEach(function (chunk) {
        // chunk 代表⼀个代码块
        // 代码块由多个模块组成,通过 chunk.forEachModule 能读取组成代码块的每个模块
        chunk.forEachModule(function (module) {
          // module 代表⼀个模块
          // module.fileDependencies 存放当前模块的所有依赖的⽂件路径,是⼀个数组
          module.fileDependencies.forEach(function (filepath) {});
        });
        // Webpack 会根据 Chunk 去⽣成输出的⽂件资源,每个 Chunk 都对应⼀个及其以上的输出⽂件
        // 例如在 Chunk 中包含了 CSS 模块并且使⽤了 ExtractTextPlugin 时,
        // 该 Chunk 就会⽣成 .js 和 .css 两个⽂件
        chunk.files.forEach(function (filename) {
          // compilation.assets 存放当前所有即将输出的资源
          // 调⽤⼀个输出资源的 source() ⽅法能获取到输出资源的内容
          let source = compilation.assets[filename].source();
        });
      });
      // 这是⼀个异步事件,要记得调⽤ callback 通知 Webpack 本次事件监听处理结束。
      // 如果忘记了调⽤ callback,Webpack 将⼀直卡在这⾥⽽不会往后执⾏。
      callback();
    })
  }
}

监听⽂件变化

在开发插件时经常需要知道是哪个⽂件发⽣变化导致了新的 Compilation,为此可以使⽤如下代码:

js
// 当依赖的⽂件发⽣变化时会触发 watch-run 事件
compiler.plugin('watch-run', (watching, callback) => {
  // 获取发⽣变化的⽂件列表
  const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
  // changedFiles 格式为键值对,键为发⽣变化的⽂件路径。
  if (changedFiles[filePath] !== undefined) {
    // filePath 对应的⽂件发⽣了变化
  }
  callback();
});
// 当依赖的⽂件发⽣变化时会触发 watch-run 事件
compiler.plugin('watch-run', (watching, callback) => {
  // 获取发⽣变化的⽂件列表
  const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
  // changedFiles 格式为键值对,键为发⽣变化的⽂件路径。
  if (changedFiles[filePath] !== undefined) {
    // filePath 对应的⽂件发⽣了变化
  }
  callback();
});

默认情况下 Webpack 只会监视⼊⼝和其依赖的模块是否发⽣变化,在有些情况下项⽬可能需要引⼊新的⽂件,例如引⼊⼀个 HTML ⽂件。 由于 JavaScript ⽂件不会去导⼊ HTML ⽂件,Webpack 就不会监听 HTML ⽂件的变化,编辑 HTML ⽂件时就不会重新触发新的 Compilation。 为了监听 HTML ⽂件的变化,我们需要把 HTML ⽂件加⼊到依赖列表中,为此可以使⽤如下代码:

js
compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML ⽂件添加到⽂件依赖列表,好让 Webpack 去监听 HTML 模块⽂件,在 HTML 模版⽂件发⽣变化时重新启动⼀次编译
  compilation.fileDependencies.push(filePath);
  callback();
});
compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML ⽂件添加到⽂件依赖列表,好让 Webpack 去监听 HTML 模块⽂件,在 HTML 模版⽂件发⽣变化时重新启动⼀次编译
  compilation.fileDependencies.push(filePath);
  callback();
});

修改输出资源

有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发⽣ emit 事件时所有模块的转换和代码块对应的⽂件已经⽣成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是⼀个键值对,键为需要输 出的⽂件名称,值为⽂件对应的内容。

设置 compilation.assets 的代码如下:

js
compiler.plugin('emit', (compilation, callback) => {
  // 设置名称为 fileName 的输出资源
  compilation.assets[fileName] = {
    // 返回⽂件内容
    source: () => {
      // fileContent 既可以是代表⽂本⽂件的字符串,也可以是代表⼆进制⽂件的 Buffer
      return fileContent;
    },
    // 返回⽂件⼤⼩
    size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});
compiler.plugin('emit', (compilation, callback) => {
  // 设置名称为 fileName 的输出资源
  compilation.assets[fileName] = {
    // 返回⽂件内容
    source: () => {
      // fileContent 既可以是代表⽂本⽂件的字符串,也可以是代表⼆进制⽂件的 Buffer
      return fileContent;
    },
    // 返回⽂件⼤⼩
    size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});

读取 compilation.assets 的代码如下:

js
compiler.plugin('emit', (compilation, callback) => {
  // 读取名称为 fileName 的输出资源
  const asset = compilation.assets[fileName];
  // 获取输出资源的内容
  asset.source();
  // 获取输出资源的⽂件⼤⼩
  asset.size();
  callback();
});
compiler.plugin('emit', (compilation, callback) => {
  // 读取名称为 fileName 的输出资源
  const asset = compilation.assets[fileName];
  // 获取输出资源的内容
  asset.source();
  // 获取输出资源的⽂件⼤⼩
  asset.size();
  callback();
});

判断webpack使⽤哪些插件

在开发⼀个插件时可能需要根据当前配置是否使⽤了其它某个插件⽽做下⼀步决定,因此需要读取Webpack 当前的插件配置情况。 以判断当前是否使⽤了 ExtractTextPlugin 为例,可以使⽤如下代码:

js
// 判断当前配置使⽤使⽤了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传⼊的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使⽤的插件列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return plugins.find(plugin => plugin.__proto__.constructor === ExtractTextPlugin) != null);
}
// 判断当前配置使⽤使⽤了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传⼊的参数
function hasExtractTextPlugin(compiler) {
  // 当前配置所有使⽤的插件列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
  return plugins.find(plugin => plugin.__proto__.constructor === ExtractTextPlugin) != null);
}

实战

实现⼀个插件,名叫 EndWebpackPlugin,作⽤是在 Webpack 即将退出时再附加⼀些额外的操作,例如在 Webpack 成功编译和输出了⽂件后执⾏发布操作把输出的⽂件上传到服务器。 同时该插件还能区分 Webpack 构建是否执⾏成功。使⽤该插件时⽅法如下:

js
module.exports = {
  plugins: [
    // 在初始化 EndWebpackPlugin 时传⼊了两个参数,分别是在成功时的回调函数和失败时的回调函数;
    new EndWebpackPlugin(() => {
      // Webpack 构建成功,并且⽂件输出了后会执⾏到这⾥,在这⾥可以做发布⽂件操作
    }, (err) => {
      // Webpack 构建失败,err 是导致错误的原因
      console.error(err);
    })
  ]
}
module.exports = {
  plugins: [
    // 在初始化 EndWebpackPlugin 时传⼊了两个参数,分别是在成功时的回调函数和失败时的回调函数;
    new EndWebpackPlugin(() => {
      // Webpack 构建成功,并且⽂件输出了后会执⾏到这⾥,在这⾥可以做发布⽂件操作
    }, (err) => {
      // Webpack 构建失败,err 是导致错误的原因
      console.error(err);
    })
  ]
}

要实现该插件,需要借助两个事件:

  • done:在成功构建并且输出了⽂件后,Webpack 即将退出时发⽣;
  • failed:在构建出现异常导致构建失败,Webpack 即将退出时发⽣;

实现该插件⾮常简单,完整代码如下:

js
class EndWebpackPlugin {
  constructor(doneCallback, failCallback) {
    // 存下在构造函数中传⼊的回调函数
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }
  apply(compiler) {
    compiler.plugin('done', (stats) => {
      // 在 done 事件中回调 doneCallback
      this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
      // 在 failed 事件中回调 failCallback
      this.failCallback(err);
    });
  }
}
// 导出插件
module.exports = EndWebpackPlugin;
class EndWebpackPlugin {
  constructor(doneCallback, failCallback) {
    // 存下在构造函数中传⼊的回调函数
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }
  apply(compiler) {
    compiler.plugin('done', (stats) => {
      // 在 done 事件中回调 doneCallback
      this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
      // 在 failed 事件中回调 failCallback
      this.failCallback(err);
    });
  }
}
// 导出插件
module.exports = EndWebpackPlugin;

从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。

扩展

常见的loader

加载文件

  • raw-loader:把⽂本⽂件的内容加载到代码中;
  • file-loader:把⽂件输出到⼀个⽂件夹中,在代码中通过相对 URL 去引⽤输出的⽂件;
  • url-loader:和 file-loader 类似,但是能在⽂件很⼩的情况下以 base64 的⽅式把⽂件内容注⼊到代码中去;
  • source-map-loader:加载额外的 Source Map ⽂件,以⽅便断点调试;
  • svg-inline-loader:把压缩后的 SVG 内容注⼊到代码中;
  • node-loader:加载 Node.js 原⽣模块 .node ⽂件;
  • image-loader:加载并且压缩图⽚⽂件;
  • json-loader:加载 JSON ⽂件;
  • yaml-loader:加载 YAML ⽂件;

编译模版

  • pug-loader:把 Pug 模版转换成 JavaScript 函数返回;
  • handlebars-loader:把 Handlebars 模版编译成函数返回;
  • ejs-loader:把 EJS 模版编译成函数返回;
  • haml-loader:把 HAML 代码转换成 HTML;
  • markdown-loader:把 Markdown ⽂件转换成 HTML;

转换脚本语⾔

  • babel-loader:把 ES6 转换成 ES5;
  • ts-loader:把 TypeScript 转换成 JavaScript;
  • awesome-typescript-loader:把 TypeScript 转换成 JavaScript,性能要⽐ ts-loader 好;
  • coffee-loader:把 CoffeeScript 转换成 JavaScript;

转换样式⽂件

  • css-loader:加载 CSS,⽀持模块化、压缩、⽂件导⼊等特性;
  • style-loader:把 CSS 代码注⼊到 JavaScript 中,通过 DOM 操作去加载 CSS;
  • sass-loader:把 SCSS/SASS 代码转换成 CSS;
  • postcss-loader:扩展 CSS 语法,使⽤下⼀代 CSS;
  • less-loader:把 Less 代码转换成 CSS 代码;
  • stylus-loader:把 Stylus 代码转换成 CSS 代码;

检查代码

  • eslint-loader:通过 ESLint 检查 JavaScript 代码;
  • tslint-loader:通过 TSLint 检查 TypeScript 代码;
  • mocha-loader:加载 Mocha 测试⽤例代码;
  • coverjs-loader:计算测试覆盖率;

常见的plugin

⽤于修改⾏为

  • define-plugin:定义环境变量;
  • context-replacement-plugin:修改 require 语句在寻找⽂件时的默认⾏为;
  • ignore-plugin:⽤于忽略部分⽂件;

⽤于优化

  • commons-chunk-plugin:提取公共代码;
  • extract-text-webpack-plugin:提取 JavaScript 中的 CSS 代码到单独的⽂件中;
  • prepack-webpack-plugin:通过 Facebook 的 Prepack 优化输出的 JavaScript 代码性能;
  • uglifyjs-webpack-plugin:通过 UglifyES 压缩 ES6 代码;
  • webpack-parallel-uglify-plugin:多进程执⾏ UglifyJS 代码压缩,提升构建速度;
  • imagemin-webpack-plugin:压缩图⽚⽂件;
  • webpack-spritesmith:⽤插件制作雪碧图;
  • ModuleConcatenationPlugin:开启 Webpack Scope Hoisting 功能;
  • dll-plugin:借鉴 DDL 的思想⼤幅度提升构建速度;
  • hot-module-replacement-plugin:开启模块热替换功能;

其它

  • serviceworker-webpack-plugin:给⽹⻚应⽤增加离线缓存功能;
  • stylelint-webpack-plugin:集成 stylelint 到项⽬;
  • i18n-webpack-plugin:给你的⽹⻚⽀持国际化;
  • provide-plugin:从环境中提供的全局变量中加载模块,⽽不⽤导⼊对应的⽂件;
  • web-webpack-plugin:⽅便的为单⻚应⽤输出 HTML,⽐ html-webpack-plugin 好⽤;